UNPKG

11.6 kBJavaScriptView Raw
1// Hacking too much time
2
3// Based on Node.js Module class sources:
4// https://github.com/nodejs/node/blob/master/lib/module.js
5
6import fs from 'fs'
7import path from 'path'
8import Module from 'module'
9
10import Log from './tools/log'
11import { exists, starts_with, ends_with } from './helpers'
12
13import serialize from './tools/serialize-javascript'
14
15const require_hacker =
16{
17 preceding_path_resolvers: [],
18 path_resolvers: [],
19
20 global_hook_resolved_modules: {},
21
22 global_hooks_enabled: true,
23
24 occupied_file_extensions: new Set(),
25
26 // logging
27 log: new Log('require-hook', { debug: false }), // this.options.debug
28
29 // installs a global require() hook for all paths
30 //
31 // (if these paths are certain to exist in the filesystem
32 // and if you need only a specific file extension
33 // then use the .hook(extension, resolve) method instead)
34 //
35 // id - a meaningful textual identifier
36 //
37 // resolve - a function which takes two parameters:
38 //
39 // the path to be resolved
40 // the module in which the require() call was originated
41 //
42 // must return either a javascript CommonJS module source code
43 // (i.e. "module.exports = ...", etc)
44 // or it can return nothing to fall back to the original Node.js loader
45 //
46 // returns an object with an .undo() method
47 //
48 // options:
49 //
50 // precede_node_loader:
51 //
52 // true - this require() hook will intercept all require() calls
53 // before they go into the original Node.js loader
54 //
55 // false - this require() hook will only intercept those require() calls
56 // which failed to be resolved by the original Node.js loader
57 //
58 // default value: true
59 //
60 global_hook(id, resolver, options = {})
61 {
62 validate.global_hook(id, resolver)
63
64 const resolver_entry =
65 {
66 id,
67 resolve: (path, module) =>
68 {
69 const resolved_path = `${path}.${id}`
70
71 // CommonJS module source code
72 const source = resolver(path, module)
73
74 if (!exists(source))
75 {
76 return
77 }
78
79 // const flush_cache = () => delete require.cache[resolved_path]
80 delete require.cache[resolved_path]
81
82 require_hacker.global_hook_resolved_modules[resolved_path] = source
83
84 return resolved_path
85 }
86 }
87
88 if (options.precede_node_loader === false)
89 {
90 require_hacker.path_resolvers.push(resolver_entry)
91 }
92 else
93 {
94 require_hacker.preceding_path_resolvers.push(resolver_entry)
95 }
96
97 const hook = this.hook(id, path =>
98 {
99 const source = require_hacker.global_hook_resolved_modules[path]
100 delete require_hacker.global_hook_resolved_modules[path]
101 return source
102 })
103
104 const result =
105 {
106 unmount: () =>
107 {
108 // javascript arrays still have no .remove() method in the XXI-st century
109 require_hacker.preceding_path_resolvers = require_hacker.preceding_path_resolvers.filter(x => x !== resolver_entry)
110 require_hacker.path_resolvers = require_hacker.path_resolvers.filter(x => x !== resolver_entry)
111 hook.unmount()
112 }
113 }
114
115 return result
116 },
117
118 // installs a require() hook for the extension
119 //
120 // extension - a file extension to hook into require()s of
121 // (examples: 'css', 'jpg', 'js')
122 //
123 // resolve - a function that takes two parameters:
124 //
125 // the path requested in the require() call
126 // the module in which the require() call was originated
127 //
128 // must return either a javascript CommonJS module source code
129 // (i.e. "module.exports = ...", etc)
130 // or it can return nothing to fall back to the original Node.js loader
131 //
132 hook(extension, resolve)
133 {
134 this.log.debug(`Hooking into *.${extension} files loading`)
135
136 // validation
137 validate.extension(extension)
138 validate.resolve(resolve)
139
140 // occupy file extension
141 this.occupied_file_extensions.add(extension)
142
143 // dotted extension
144 const dot_extension = `.${extension}`
145
146 // keep original extension loader
147 const original_loader = Module._extensions[dot_extension]
148
149 // display a warning in case of extension loader override
150 if (original_loader)
151 {
152 // output a debug message in case of extension loader override,
153 // not a warning, so that it doesn't scare people
154 this.log.debug(`-----------------------------------------------`)
155 this.log.debug(`Overriding an already existing require() hook `)
156 this.log.debug(`for file extension ${dot_extension}`)
157 this.log.debug(`-----------------------------------------------`)
158 }
159
160 // the list of cached modules
161 const cached_modules = new Set()
162
163 // Node.js inner API check
164 /* istanbul ignore if */
165 if (!Module._extensions)
166 {
167 throw new Error('Incompatilbe Node.js version detected: "Module._extensions" array is missing. File an issue on GitHub.')
168 }
169
170 // set new loader for this extension
171 Module._extensions[dot_extension] = (module, filename) =>
172 {
173 this.log.debug(`Loading source code for ${filename}`)
174
175 // var source = fs.readFileSync(filename, 'utf8')
176 const source = resolve(filename, module)
177
178 if (!exists(source))
179 {
180 this.log.debug(`Fallback to original loader`)
181
182 // this message would appear if there was no loader
183 // for the extension of the filename
184 if (path.extname(filename) !== dot_extension)
185 {
186 this.log.info(`Trying to load "${path.basename(filename)}" as a "*${dot_extension}"`)
187 }
188
189 // load the file with the original loader
190 return (original_loader || Module._extensions['.js'])(module, filename)
191 }
192
193 // add this file path to the list of cached modules
194 cached_modules.add(filename)
195
196 // Node.js inner API check
197 /* istanbul ignore if */
198 if (!module._compile)
199 {
200 throw new Error('Incompatilbe Node.js version detected: "Module.prototype._compile" function is missing. File an issue on GitHub.')
201 }
202
203 // compile javascript module from its source
204 // https://github.com/nodejs/node/blob/master/lib/module.js#L379
205 module._compile(source, filename)
206 }
207
208 const result =
209 {
210 // uninstall the hook
211 unmount: () =>
212 {
213 // clear require() cache for this file extension
214 for (let path of cached_modules)
215 {
216 delete require.cache[path]
217 }
218
219 // mount the original loader for this file extension
220 Module._extensions[dot_extension] = original_loader
221
222 // free file extension
223 this.occupied_file_extensions.delete(extension)
224 }
225 }
226
227 return result
228 },
229
230 // returns a CommonJS modules source.
231 to_javascript_module_source(anything)
232 {
233 // if the asset source wasn't found - return an empty CommonJS module
234 if (!exists(anything))
235 {
236 return 'module.exports = undefined'
237 }
238
239 // if it's already a common js module source
240 if (typeof anything === 'string' && is_a_module_declaration(anything))
241 {
242 return anything
243 }
244
245 // generate javascript module source code based on the `source` variable
246 return 'module.exports = ' + serialize(anything)
247 },
248
249 // resolves a requireable `path` to a real filesystem path relative to the `module`
250 // (resolves `npm link`, etc)
251 resolve(path_to_resolve, module)
252 {
253 // https://nodejs.org/api/modules.html#modules_file_modules
254
255 // if it's an absolute path then return it
256 if (starts_with(path_to_resolve, '/'))
257 {
258 return path_to_resolve
259 }
260
261 // if it's a relative path then simply resolve it against module.filename
262 if (path_to_resolve === '.'
263 || path_to_resolve === '..'
264 || starts_with(path_to_resolve, './')
265 || starts_with(path_to_resolve, '../'))
266 {
267 return path.resolve(path.dirname(module.filename), path_to_resolve)
268 }
269
270 // Module._resolveFilename existence check is perfomed outside of this method
271 try
272 {
273 require_hacker.global_hooks_enabled = false
274 return Module._resolveFilename(path_to_resolve, module)
275 }
276 finally
277 {
278 require_hacker.global_hooks_enabled = true
279 }
280 }
281}
282
283// validation
284const validate =
285{
286 extension(extension)
287 {
288 // if (typeof extension !== 'string')
289 // {
290 // throw new Error(`Expected string extension. Got ${extension}`)
291 // }
292
293 if (path.extname(`test.${extension}`) !== `.${extension}`)
294 {
295 throw new Error(`Invalid file extension "${extension}"`)
296 }
297
298 // check if the file extension is already occupied
299 if (require_hacker.occupied_file_extensions.has(extension))
300 {
301 throw new Error(`File extension "${extension}" is already occupied by require-hacker`)
302 }
303 },
304
305 resolve(resolve)
306 {
307 if (typeof resolve !== 'function')
308 {
309 throw new Error(`Resolve should be a function. Got "${resolve}"`)
310 }
311 },
312
313 global_hook(id, resolver)
314 {
315 if (!id)
316 {
317 throw new Error(`You must specify global hook id`)
318 }
319
320 if (path.extname(`test.${id}`) !== `.${id}`)
321 {
322 throw new Error(`Invalid global hook id "${id}". Expected a valid file extension.`)
323 }
324
325 // check if the file extension is already occupied
326 if (require_hacker.occupied_file_extensions.has(id))
327 {
328 throw new Error(`File extension "${id}" is already occupied by require-hacker`)
329 }
330
331 validate.resolve(resolver)
332 }
333}
334
335// Node.js inner API check
336/* istanbul ignore if */
337if (!Module._resolveFilename)
338{
339 throw new Error('Incompatilbe Node.js version detected: "Module._resolveFilename" function is missing. File an issue on GitHub.')
340}
341
342// Node.js inner API check
343/* istanbul ignore if */
344if (!Module._findPath)
345{
346 throw new Error('Incompatilbe Node.js version detected: "Module._findPath" function is missing. File an issue on GitHub.')
347}
348
349// the module in which the require() call originated
350let require_caller
351
352// instrument Module._resolveFilename
353// https://github.com/nodejs/node/blob/master/lib/module.js#L322
354//
355// `arguments` would conflict with Babel, therefore `...parameters`
356//
357// const native_module = require('native_module')
358const original_resolveFilename = Module._resolveFilename
359Module._resolveFilename = function(...parameters)
360{
361 const request = parameters[0]
362 const parent = parameters[1]
363
364 // take note of the require() caller
365 require_caller = parent
366
367 return original_resolveFilename.apply(this, parameters)
368}
369
370// instrument Module._findPath
371// https://github.com/nodejs/node/blob/master/lib/module.js#L335-L341
372//
373// `arguments` would conflict with Babel, therefore `...parameters`
374//
375const original_findPath = Module._findPath
376Module._findPath = (...parameters) =>
377{
378 const request = parameters[0]
379 // const paths = parameters[1]
380
381 // preceeding resolvers
382 if (require_hacker.global_hooks_enabled)
383 {
384 for (let resolver of require_hacker.preceding_path_resolvers)
385 {
386 const resolved = resolver.resolve(request, require_caller)
387 if (exists(resolved))
388 {
389 return resolved
390 }
391 }
392 }
393
394 // original Node.js loader
395 const filename = original_findPath.apply(undefined, parameters)
396 if (filename !== false)
397 {
398 return filename
399 }
400
401 // rest resolvers
402 if (require_hacker.global_hooks_enabled)
403 {
404 for (let resolver of require_hacker.path_resolvers)
405 {
406 const resolved = resolver.resolve(request, require_caller)
407 if (exists(resolved))
408 {
409 return resolved
410 }
411 }
412 }
413
414 return false
415}
416
417// detect if it is a CommonJS module declaration
418function is_a_module_declaration(text)
419{
420 return text.indexOf('module.exports = ') === 0 ||
421 /\s+module\.exports = .+/.test(text)
422}
423
424export default require_hacker
\No newline at end of file